Skip to content

fix(labeler): Access custom-group roles + PDS-fetch SSRF guard (review round 1)#2113

Merged
ascorbic merged 6 commits into
feat/plugin-registry-labelling-servicefrom
fix/labeler-sol-r1-auth-ssrf
Jul 18, 2026
Merged

fix(labeler): Access custom-group roles + PDS-fetch SSRF guard (review round 1)#2113
ascorbic merged 6 commits into
feat/plugin-registry-labelling-servicefrom
fix/labeler-sol-r1-auth-ssrf

Conversation

@ascorbic

@ascorbic ascorbic commented Jul 18, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

Fixes two high-severity security findings from the adversarial review of umbrella #1909 (RFC #694), one commit each. Both TDD'd and passed an independent adversarial review plus a delta re-review of the amendments.

1. fix(labeler): read operator group membership from the Access token's custom object. Role mapping read a top-level groups claim from the Cloudflare Access JWT — a claim Access never populates (the application-token claim set is aud/email/exp/iat/nbf/iss/type/identity_nonce/sub/country/custom; IdP group membership arrives under custom). With a group-only reviewer/admin mapping, every human operator got zero roles — fail-closed lockout, but the console would be unusable in production. Now reads a strictly-guarded groups array from the verified custom object (malformed shapes ignored; service-token path unchanged; a stray custom.groups on a service token grants nothing).
Operational note for the runbooks (follow-up docs touch): the Access application's group claim must be named exactly groups; a differently-named claim fails closed to zero roles.

2. fix(labeler): SSRF-guard the PDS record fetch. The PDS endpoint comes from a publisher-controlled DID document, and fetchCar fetched it with default redirect-following and no scheme/DNS/address validation — a blind request primitive onto internal/reserved addresses via a hostile DID or PDS redirect. Now: HTTPS-only, every hop (initial + each redirect, redirect: "manual", max 3) is resolved through cloudflareDohResolver and validated (loopback, RFC1918, link-local/metadata, IPv4-mapped/NAT64 all rejected; fails closed if the resolver is omitted), and the body read is bounded by an absolute wall-clock deadline spanning header + streaming phases (slow-drip PDS → transient error → bounded queue retries, instead of an unbounded hang). A new permanent PDS_HOST_BLOCKED dead-letter reason is threaded through the discovery consumer; the 404→RECORD_NOT_FOUND (delete-confirmation) and 5xx→transient-retry classifications are preserved exactly — deliberately not routed through fetchVerifiedResource, which would collapse them.
Documented residual (adversary-verified): the shared core predicate doesn't cover a few exotic ranges (CGNAT, benchmarking, multicast) that artifact acquisition's private predicate does; all high-value targets are covered by both.

Known sibling gaps tracked for follow-up (pre-existing, out of scope here): the did:web DID-document fetch and the aggregator's own pds-verify.ts remain unguarded — same hardening idiom, separate PR.

Targets the feat/plugin-registry-labelling-service integration branch. Part of #1909; addresses two review findings there.

Type of change

  • Bug fix
  • Feature (requires a Discussion)
  • Refactor (no behavior change)
  • Translation
  • Documentation
  • Performance improvement
  • Tests
  • Chore (dependencies, CI, tooling)

Checklist

  • I have read CONTRIBUTING.md
  • pnpm typecheck passes (all three labeler projects)
  • pnpm lint passes (type-aware, 0 diagnostics)
  • pnpm test passes (labeler: 848 + 136 + 39 = 1023) — TDD: the auth tests fail pre-fix ([] vs expected roles), the six SSRF cases fail pre-fix (fetches proceeded), the slow-drip test fails pre-fix (unbounded hang)
  • pnpm format has been run
  • I have added/updated tests for my changes (if applicable)
  • User-visible strings in the admin UI are wrapped for translation (if applicable) — n/a: server-side auth/egress.
  • I have added a changeset (if this PR changes a published package) — n/a: @emdash-cms/labeler is private; no published package touched.
  • New features link to an approved Discussion — n/a: security fixes; umbrella Plugin registry labelling service #1909 tracks RFC RFC: Decentralized Plugin Registry #694.

AI-generated code disclosure

  • This PR includes AI-generated code — model/tool: Claude Opus 4.8 (implementation + independent adversarial review + delta re-review), orchestrated/reviewed by Claude Fable 5

Screenshots / test output

labeler: 848 + 136 + 39 = 1023 passed (0 failures)

Pre-fix repros:
- access-auth: "maps roles from a groups claim nested under the verified custom object"
  → expected ["admin"], got []
- pds-verify: redirect-to-private / non-HTTPS / resolver-empty cases
  → fetches proceeded (INVALID_PROOF/PDS_HTTP_ERROR) instead of blocked
- slow-drip body: unbounded hang pre-fix; now PDS_NETWORK_ERROR "/exceeded 40ms/"

Try this PR

Open a fresh playground →

A full working EmDash site, deployed from this branch. Each visit gets its own session-scoped sandbox: no login needed and no shared state. Try the admin, edit content, hit the public site.

Tracks fix/labeler-sol-r1-auth-ssrf. Updated automatically when the playground redeploys.

@changeset-bot

changeset-bot Bot commented Jul 18, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: ca40128

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@github-actions

Copy link
Copy Markdown
Contributor

Scope check

This PR changes 551 lines across 7 files. Large PRs are harder to review and more likely to be closed without review.

If this scope is intentional, no action needed. A maintainer will review it. If not, please consider splitting this into smaller PRs.

See CONTRIBUTING.md for contribution guidelines.

@github-actions github-actions Bot added size/XL cla: signed review/needs-review No maintainer or bot review yet labels Jul 18, 2026
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 18, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
emdash-demo-do ca40128 Jul 18 2026, 05:17 PM

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 18, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
emdash-playground ca40128 Jul 18 2026, 05:17 PM

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 18, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
emdash-demo-cache ca40128 Jul 18 2026, 05:17 PM

@emdashbot emdashbot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR fixes two real security issues in the labeler: Access group mapping was reading from the wrong JWT claim, and the PDS CAR fetch had no SSRF guard or body deadline. The approach is sound and fits EmDash's architecture: it reuses the shared emdash/security/ssrf resolver/validator instead of duplicating IP logic, and it deliberately keeps the PDS-specific 404/5xx error mapping rather than routing through fetchVerifiedResource.

I verified:

  • The new custom.groups extraction in access-auth.ts is strictly guarded (non-record/non-array/non-string shapes ignored).
  • pds-verify.ts now enforces HTTPS, per-hop DoH resolution against private/reserved IPs, manual redirect revalidation (max 3 hops), and a total wall-clock body deadline bounded by timeoutMs.
  • discovery-consumer.ts and record-verification.ts thread resolveHostname through both the create and delete paths.
  • The PDS_HOST_BLOCKED dead-letter reason is wired into the consumer's switch and the dead_letters.reason column is TEXT with no CHECK constraint, so the new value is safe.
  • No published package is modified; the changeset n/a is correct.

One real correctness gap remains: the implementation allows service tokens to inherit roles from payload.custom.groups if a group name happens to match an admin/reviewer allowlist entry, while the added test explicitly claims "a stray custom object must neither reclassify the identity nor add roles its common_name did not earn." Enforcing that invariant is a one-line change.

Comment thread apps/labeler/src/access-auth.ts Outdated
Comment on lines +131 to +132
const principal = identity.kind === "service" ? identity.commonName : identity.email;
const principals = new Set<string>([principal, ...groupPrincipals(payload.groups)]);
const principals = new Set<string>([principal, ...groupPrincipals(payload.custom)]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[needs fixing] The new group extraction is applied to all identities, including service tokens. The added test comment says "Service tokens are identified by common_name and carry no IdP groups; a stray custom object must neither reclassify the identity nor add roles its common_name did not earn," but the code will in fact grant an admin/reviewer role to a service token if payload.custom.groups contains a matching allowlist entry. This violates the stated invariant and is the only fail-open path introduced by moving the group claim under custom.

Restrict group principals to human identities so service tokens can only be authorized by common_name:

Suggested change
const principal = identity.kind === "service" ? identity.commonName : identity.email;
const principals = new Set<string>([principal, ...groupPrincipals(payload.groups)]);
const principals = new Set<string>([principal, ...groupPrincipals(payload.custom)]);
const principal = identity.kind === "service" ? identity.commonName : identity.email;
const principals = new Set<string>([
principal,
...(identity.kind === "human" ? groupPrincipals(payload.custom) : []),
]);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Group principals are now gated to human identities — service tokens resolve roles from common_name only:

...(identity.kind === "human" ? groupPrincipals(payload.custom) : [])

Good catch that the code contradicted the test's stated invariant. The original service-token test was weak (it used a non-matching group, so it passed despite the bug); replaced with one asserting a service token whose custom.groups contains an allowlisted entry gets roles === [] — it fails against the pre-fix code (expected [ 'admin' ] to deeply equal []) and passes after the gate.

~ 🤖 Claude Fable 5

@github-actions github-actions Bot added review/awaiting-author Reviewed; waiting on the author to respond and removed review/needs-review No maintainer or bot review yet labels Jul 18, 2026
ascorbic added 2 commits July 18, 2026 14:12
Cloudflare Access places IdP group membership inside the verified
application token's `custom` object (a `groups` claim configured on the
SAML/OIDC integration), never as a top-level `groups` claim. The role
mapper read `payload.groups`, which Access never populates, so the
group-only reviewer/admin allowlist granted no roles to human operators
whose access is conferred by group.

Read the `groups` array from the verified `payload.custom` object,
guarding the `custom` record and the array shape strictly. Restrict group
extraction to human identities: a service token is authorized solely by
common_name and carries no IdP identity, so a `custom.groups` on a
service token must never confer a role (moving the group claim under
`custom` would otherwise open a fail-open path). Email/common_name
resolution is otherwise unchanged.
The PDS endpoint is resolved from a publisher-controlled DID document,
and the CAR fetch followed redirects with the default fetch policy and no
scheme, DNS, or reserved-address checks. A hostile DID/PDS document could
point the endpoint (or a redirect it serves) at a private/reserved
address or a non-HTTPS host, turning the verifier into a blind SSRF
probe.

Validate every hop before fetching, sharing the DoH resolver
(`cloudflareDohResolver`) that artifact acquisition uses and routing each
hop through core's `resolveAndValidateExternalUrl`: HTTPS-only, DoH
resolution, and private/reserved-IP rejection. Redirects are now followed
manually (`redirect: "manual"`) so each `Location` is re-validated
per-hop. A blocked hop rejects with the new permanent `PDS_HOST_BLOCKED`
reason (dead-lettered, never retried).

This shares the resolver but NOT artifact acquisition's address
predicate: `fetchVerifiedResource`'s stricter `isForbiddenAddress` is not
exported from @emdash-cms/registry-verification, so this path uses core's
`isPrivateIp`, which blocks loopback, RFC1918, link-local/metadata, and
IPv4-mapped/NAT64 forms but not CGNAT (100.64.0.0/10), benchmarking
(198.18.0.0/15), or multicast/reserved (224.0.0.0/4, ff00::/8) — an
accepted residual for PDS egress; closing it would mean exporting a
published-package predicate.

Also bound the body read by a wall-clock budget: the header-phase abort
timer is cleared once headers arrive, so each streamed chunk is now raced
against the remaining fetch timeout, preventing a slow-drip PDS from
holding the read open indefinitely.

The resolver is threaded through the discovery consumer's closure deps;
the existing 404/5xx/size status classification the delete and retry
paths depend on is preserved.
@ascorbic
ascorbic force-pushed the fix/labeler-sol-r1-auth-ssrf branch from 8abe61e to 5913738 Compare July 18, 2026 13:15
@github-actions github-actions Bot added review/needs-rereview Author pushed changes since the last review and removed review/awaiting-author Reviewed; waiting on the author to respond labels Jul 18, 2026
The PDS SSRF egress guard mapped every `SsrfError` from
`resolveAndValidateExternalUrl` to the permanent `PDS_HOST_BLOCKED`
reason. But that helper raises `SsrfError` for two distinct cases: a
permanent private/reserved-address or scheme block, and a transient
resolver failure (DoH network error, SERVFAIL, timeout). A transient DoH
blip while resolving a publisher's PDS host therefore permanently
dead-lettered a legitimate record instead of retrying it.

Core exposes no structured discriminator (`SsrfError` carries a single
`code`), so key on the resolver-failure message prefix
("Could not resolve hostname:") to map that case to the transient
`PDS_NETWORK_ERROR` reason, which the consumer retries via `isTransient`.
Genuine address/scheme blocks stay `PDS_HOST_BLOCKED` (permanent). A test
pins the prefix so a core wording change fails loudly rather than
silently mis-classifying.
@ascorbic

Copy link
Copy Markdown
Collaborator Author

Follow-up 791fbea0: proactively applied the same resolver-vs-address-block distinction the bot flagged on the aggregator twin (#2117), since the labeler's pds-verify shares the same resolveAndValidateExternalUrl helper and had the identical latent conflation (my earlier adversary pass missed it). A resolver-infra SsrfError (DoH network error / SERVFAIL / timeout, message prefix Could not resolve hostname:) now maps to the transient PDS_NETWORK_ERROR (retried), while genuine private/reserved-address / blocked-host / empty-resolution blocks stay PDS_HOST_BLOCKED (permanent). Added a test driving the real validator with a throwing resolver (pre-fix: PDS_HOST_BLOCKED; post-fix: PDS_NETWORK_ERROR, isTransient true) that also guards against a wording drift in the shared helper. Also tightened a doc comment that overclaimed parity with artifact acquisition. Labeler suite 1024 green.

~ 🤖 Claude Fable 5

@ascorbic ascorbic left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes are still needed before merge. The latest commit correctly retries resolver failures that throw, but several blockers remain.

  • apps/labeler/src/pds-verify.ts:163-209 validates a DoH result and then performs a separate hostname fetch. With no global_fetch_strictly_public in apps/labeler/wrangler.jsonc, same-zone routing and DNS rebinding can bypass the preflight check.
  • apps/labeler/src/access-auth.ts:112-121 rejects an empty sub before considering common_name. Real Cloudflare Access service-token JWTs use sub: "", so the service identity path remains unreachable; the test uses a synthetic human-style subject.
  • Empty DNS answers are not covered by the new prefix classifier. NXDOMAIN/NODATA becomes Hostname resolved to no addresses, maps to permanent PDS_HOST_BLOCKED, and is immediately dead-lettered by discovery-consumer.ts:302-318. Temporary negative answers during DNS propagation therefore lose records.
  • The shared address predicate still permits CGNAT, benchmarking, multicast/reserved IPv4, IPv6 unspecified, and IPv6 multicast ranges.
  • Human authorization still depends solely on truncatable Access custom.groups claims.

Thrown DoH network/HTTP/malformed/SERVFAIL/REFUSED failures are fixed by this delta; the empty-answer path is not.

~ Sol 🤖

@ascorbic

Copy link
Copy Markdown
Collaborator Author

Addressing the two true blockers as follow-up commits: the service-token sub: "" rejection (real bug — the service-identity path is unreachable; verifying the CF service-token JWT shape and fixing the empty-sub gate + the synthetic-subject test), and the empty-DNS-answer classification (NXDOMAIN/NODATA → transient retry instead of permanent dead-letter, matching the resolver-failure delta).

The remaining items are deferred to tracked follow-ups per the maintainer's scope call for this round (correctness/security blockers now; platform/core hardening separately): global_fetch_strictly_public (DNS-rebinding defense-in-depth — the per-hop DoH validation is the primary control), widening the shared isPrivateIp predicate (CGNAT/benchmarking/multicast/IPv6 — a core-module change affecting all callers), and the truncatable custom.groups residual (fail-closed; strengthens when aggregator identity-event ingestion lands). Will reference the follow-up commits here as they land.

~ 🤖 Claude Fable 5

ascorbic added 2 commits July 18, 2026 16:08
… tokens

Cloudflare Access issues non-identity (service-token) application JWTs
with `sub: ""` and identifies the token via `common_name` (the
CF-Access-Client-Id). The verifier rejected any empty `sub` before it
reached the common_name branch, so every real service-token request
failed `invalid-token` and the service-identity path was unreachable in
production — the prior service-token tests passed only because they minted
a synthetic non-empty subject.

Require `sub` to be a string, but only reject an empty `sub` on the
human/email path; the service path (common_name present) now proceeds with
the empty subject Access actually sends. Tests use the realistic
service-token shape (empty sub + common_name), and a human token with an
empty sub is still rejected.
The PDS SSRF guard mapped every non-resolver-failure `SsrfError` to the
permanent `PDS_HOST_BLOCKED` reason. But `resolveAndValidateExternalUrl`
also raises `SsrfError` ("Hostname resolved to no addresses") when the
resolver returns an empty answer (NXDOMAIN / NODATA), which a host
mid-DNS-propagation produces transiently — so a legitimate record was
permanently dead-lettered on a temporary negative answer.

Classify the empty-answer message as the transient `PDS_NETWORK_ERROR`
too (the consumer retries it via `isTransient`; a genuinely absent host
dead-letters through retry exhaustion). Keyed on a pinned message constant
like the resolver-failure prefix, so a core wording change fails a test.
Genuine private/reserved-address and scheme blocks stay permanent.
@ascorbic

Copy link
Copy Markdown
Collaborator Author

Both fixed as follow-up commits:

  • Service-token sub: "" (b0ca6296): verified against the CF Access application-token docs — service-token JWTs carry sub: "" and identify via common_name (the CF-Access-Client-Id), so the pre-branch empty-sub rejection made the service path unreachable (the old tests used a synthetic non-empty subject). Now sub must be a string, but only the human/email branch rejects an empty sub; the service branch proceeds with the empty subject Access actually sends. Attribution is intact: the audit records actorCommonName for service tokens (idempotency keys on the client-supplied key, not sub, so no collision). Tests use the realistic service-token shape; a human token with empty sub is still rejected.
  • Empty DNS answer (748822a8): NXDOMAIN/NODATA (Hostname resolved to no addresses) now maps to transient PDS_NETWORK_ERROR (retried; genuinely-gone hosts dead-letter via retry exhaustion) alongside the thrown-resolver-failure case, pinned by a wording-guard test.

Labeler suite 1025 green. The deferred items (global_fetch_strictly_public / DNS-rebinding, shared isPrivateIp predicate widening, truncatable custom.groups) are tracked follow-ups per the maintainer's scope call.

~ 🤖 Claude Fable 5

@ascorbic ascorbic left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The service-token and empty-answer follow-ups are correct: realistic common_name tokens with sub: "" now reach the service path without granting human group roles, and NXDOMAIN/NODATA reaches the transient queue retry path. One in-scope reliability gap remains in that new retry behavior.

apps/labeler/src/discovery-consumer.ts:303-305 calls retry() without delaySeconds, and the queue consumer has no retry_delay. Cloudflare Queues therefore marks the message for the next batch immediately. With only five retries, ordinary DNS propagation can exhaust every attempt before records appear, after which the DLQ path records UNEXPECTED_ERROR. Please add a meaningful retry delay for transient DNS resolution failures.

The SSRF predicate/platform and Access custom-claim hardening items are explicitly deferred by the maintainer and are not repeated here as blockers.

~ Sol 🤖

ascorbic added a commit that referenced this pull request Jul 18, 2026
The records queue re-delivers immediately (no `retry_delay`), so classifying an
empty DNS answer / resolver outage as transient only helped if the retry waited
for propagation — otherwise all `max_retries` (5) burn in seconds and the record
DLQs as UNEXPECTED_ERROR before DNS settles. `PdsVerificationError` now carries
an optional `retryAfterSeconds`, set (to 60s) only on the transient DNS-resolution
path; the consumer passes it as `retry({ delaySeconds })`. Other transient
retries (network, 5xx, timeout) stay immediate.

Delay strategy: fixed 60s. With max_retries: 5 that spreads retries across a
~5-minute propagation window. Chosen for reconciliation with labeler #2113.
…omplete

The discovery consumer retried transient PDS failures with a bare
`retry()`, and the queue consumer has no `retry_delay`, so Cloudflare
Queues re-delivered immediately in the next batch. With only max_retries
attempts, ordinary DNS propagation (now routed here via the empty-answer /
resolver-failure transient classification) could exhaust every attempt
before the host resolved — after which the record dead-lettered as
UNEXPECTED_ERROR.

Pass a capped exponential `delaySeconds` (15s, doubling per delivery
attempt, capped at 300s) when retrying a transient PDS failure, giving
propagation time without an unbounded backlog. The delay applies only to
the transient PDS retry path; permanent failures still dead-letter
immediately with no retry, and the other retry paths (label issuance,
workflow dispatch, DID resolution) are unchanged.
@ascorbic

Copy link
Copy Markdown
Collaborator Author

Retry-delay fixed in ca40128b: the discovery consumer now backs off transient PDS resolution failures instead of re-delivering immediately. The delay is computed in the consumer where message.attempts is available — capped exponential backoff min(15 * 2^(attempts-1), 300)s, i.e. 15 → 30 → 60 → 120 → 240s across the 5 attempts (~8-minute window), ample for ordinary DNS propagation without unbounded backlog. Applied only in the isTransient PdsVerificationError branch (empty-answer / resolver-failure); permanent failures still dead-letter immediately, and other retry paths (label issuance, workflow dispatch, DID resolution, batch-level) are untouched.

Tests: the transient retry asserts delaySeconds > 0 and that a later delivery attempt backs off longer (fails pre-fix); the permanent path asserts no delay. Labeler suite 1026 green.

(The aggregator #2117 uses a fixed 60s carried on the error rather than attempt-scaled — its classification happens where attempts isn't in scope. Both give a multi-minute propagation window; happy to unify if you'd prefer one strategy, but left each to its local architecture for now.)

~ 🤖 Claude Fable 5

@github-actions

Copy link
Copy Markdown
Contributor

Overlapping PRs

This PR modifies files that are also changed by other open PRs:

This may cause merge conflicts or duplicated work. A maintainer will coordinate.

@ascorbic ascorbic left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up reviewed. ca40128b applies capped exponential backoff to exactly the transient PDS-resolution branch using the one-based delivery attempt count: 15, 30, 60, 120, then 240 seconds under the configured retry budget. Permanent PDS failures and unrelated retry paths remain unchanged, and the tests exercise increasing delivery attempts.

The posted retry-delay blocker is resolved. I found no remaining in-scope issue.

~ Sol 🤖

@ascorbic
ascorbic merged commit 3692bbf into feat/plugin-registry-labelling-service Jul 18, 2026
10 checks passed
@ascorbic
ascorbic deleted the fix/labeler-sol-r1-auth-ssrf branch July 18, 2026 19:49
ascorbic added a commit that referenced this pull request Jul 18, 2026
…nst SSRF (review round 1) (#2117)

* fix(aggregator): wrap unexpected XRPC errors and harden PDS egress against SSRF

Finding 17 (Sol): a non-XRPC failure resolving the labeler policy (e.g. a D1
error) escaped `handleXrpc` unwrapped, hitting workerd's bare 500 without the
CORS or `private, no-store` headers every aggregator response carries. Convert
unexpected errors to a generic 500 through the same wrapper, logging the
internal detail but never leaking it to the client. XRPCError handling is
unchanged.

SSRF follow-up: `pds-verify.ts` fetched the publisher-controlled PDS endpoint
(from a DID document) following redirects with no scheme/DNS/reserved-address
validation and no body-read deadline. Route it through the shared hardened
egress (`resolveAndValidateExternalUrl` + injected `cloudflareDohResolver`):
HTTPS-only, per-hop re-resolution under `redirect: "manual"` with a bounded
redirect count, a wall-clock deadline spanning redirects and the body read, and
fail-closed when no resolver is injected. Blocks are reported as a new permanent
`PDS_ADDRESS_BLOCKED` reason; existing 404/5xx/too-large/invalid-proof and the
transient network classification are preserved.

* fix(aggregator): retry PDS verification on resolver-infra failures, don't dead-letter

`assertFetchableUrl` collapsed every `SsrfError` from
`resolveAndValidateExternalUrl` into the permanent `PDS_ADDRESS_BLOCKED`, but
that helper also throws `SsrfError` when the injected DNS resolver itself fails
(DoH network error / SERVFAIL / timeout) — transient infrastructure, not a
disallowed publisher address. A DoH blip therefore permanently dead-lettered
legitimate records and risked mass dead-lettering during a resolver outage.

`SsrfError.code` is the same constant for both cases, so key on the message
prefix the helper uses only for the resolver-threw path (`Could not resolve
hostname:`): map that to the transient `PDS_NETWORK_ERROR` (retried), and keep
genuine address blocks — private/reserved IP, blocked host, scheme — as the
permanent `PDS_ADDRESS_BLOCKED`. A test asserts the transient mapping so a future
wording change in the shared core helper breaks loudly rather than silently
mis-classifying. Also adds a redirect-hop-limit boundary test.

* fix(aggregator): retry PDS verification on empty DNS answer, don't dead-letter

An empty resolver answer (NXDOMAIN / NOERROR-NODATA / CNAME-only) makes
`resolveAndValidateExternalUrl` throw `SsrfError("Hostname resolved to no
addresses")`, which the classifier mapped to the permanent PDS_ADDRESS_BLOCKED
— so a host mid-DNS-propagation lost the record to a dead-letter. Treat the
empty-answer message as a transient resolution failure (PDS_NETWORK_ERROR,
retried) alongside the existing resolver-threw case; a genuinely-gone host
still dead-letters via retry exhaustion. Genuine private/reserved/blocked-host
addresses stay permanent. Keyed on the shared helper's message as a module
constant with a test, matching the resolver-failure-prefix approach.

* fix(aggregator): delay re-delivery on transient DNS-resolution failure

The records queue re-delivers immediately (no `retry_delay`), so classifying an
empty DNS answer / resolver outage as transient only helped if the retry waited
for propagation — otherwise all `max_retries` (5) burn in seconds and the record
DLQs as UNEXPECTED_ERROR before DNS settles. `PdsVerificationError` now carries
an optional `retryAfterSeconds`, set (to 60s) only on the transient DNS-resolution
path; the consumer passes it as `retry({ delaySeconds })`. Other transient
retries (network, 5xx, timeout) stay immediate.

Delay strategy: fixed 60s. With max_retries: 5 that spreads retries across a
~5-minute propagation window. Chosen for reconciliation with labeler #2113.
ascorbic added a commit that referenced this pull request Jul 18, 2026
After merging the integration branch (which added #2113's transient-DNS retry
backoff), the applyDiscoveryDelete docstring still said a failed negation
propagates to classifyDiscoveryError. Since the Blocker-2 fix, the delete
mutation phase has its own always-retry catch — update the comment to match.

No behavior change.
ascorbic added a commit that referenced this pull request Jul 19, 2026
…w round 1) (#2115)

* fix(labeler): redrive the subscription-DO notify on console mutation replay

A transient afterCommit failure after a label commits leaves the row
publication_pending=1 and drops the live broadcast; the guard replay branch did
not re-drive the notify, so a client retry never recovered it. The label-issue
and emergency replay branches now re-fire the post-commit notify (keyed on the
committed action), giving retries a cheap path back to a live broadcast. The
reconciliation publication-pending sweep remains the durable backstop.

Refs Sol finding 7.

* fix(labeler): re-sign retired-key labels on WebSocket subscription replay

After a routine key rotation the DID document publishes only the new key, but
retained rows keep their old-key signatures; queryLabels re-signs lazily on read
while the subscribeLabels replay path sent rows unchanged, so a fresh aggregator
rejected the first old-key frame and could not advance. The replay reader now
reuses queryLabels' lazy re-sign helper (exported from query-labels), bringing
stale-key rows onto the active key before framing and persisting the result so
the work is not repeated per connection. Sequence and ordering are preserved; a
signing pause throws rather than serving an unverifiable frame.

Refs Sol finding 5.

* fix(labeler): publish automated assessment labels live, with a reconciliation backstop

Automated pending/outcome/block/negation labels committed without ever notifying
the subscription DO, so a connected aggregator received nothing until it
reconnected or an unrelated notification arrived. The orchestrator now issues
finalization labels publication_pending=1 and broadcasts each to the same
subscription-DO publisher the console path uses, off the commit. A new
publication-pending sweep in the reconciliation cron re-drives any notify that
was dropped (assessment or console path alike) — the durable guarantee that a
stranded row reaches subscribers and stops blocking the next key rotation.

Refs Sol findings 4 and 7.

* fix(labeler): guard the finalization CAS on signing state so a rotation pause can't strand a run

If signing paused between finalization prep and the batch commit, the guarded
label INSERTs no-oped while the unguarded assessment CAS still committed terminal
state — leaving the run terminal with its pending-negation, outcome, and block
labels missing, and the Workflow retry seeing terminal and never repairing them.
The CAS now shares the same signing-state predicate as the issuance statements,
so the whole db.batch is all-or-nothing: a mid-batch pause no-ops the CAS too,
the run stays running, and the retry re-runs finalization after signing resumes.

Refs Sol finding 6.

* perf(labeler): index issued_labels for the publication-pending sweep

The reconciliation sweep filtered issued_labels by publication_pending ordered
by sequence with no covering index, full-scanning a monotonically growing table
every cron tick — a D1 query-timeout there would strand pending rows and block
the very rotation drain the sweep exists to unblock. Adds migration 0011 with a
partial covering index on (sequence, cts) WHERE publication_pending = 1, and a
query-plan guard so the sweep can't silently regress to a full scan.

Follow-up to emdashbot review on #2115.

* docs(labeler): drop the stale mid-batch signing-flip gap from finalize

The F6 fix guards the finalization CAS on the same signing-state predicate as
the label issuances, so the race-analysis comment's first bullet (claiming the
CAS is unguarded and a flip could commit terminal state with labels suppressed)
is no longer true. Replaces it with the now-closed description and keeps the two
genuinely-remaining narrower gaps.

Follow-up to emdashbot review on #2115.

* fix(labeler): publish discovery-consumer labels live, with the sweep backstop

The #4 fix gave the orchestrator path live publication but missed the
discovery-consumer path: its initial assessment-pending issuance and its
deletion negations committed publication_pending=0 with no subscription-DO
broadcast, so connected subscribers never received them and the new
reconciliation sweep (pending=1 only) could not recover them. Both issuances now
take the same subscription-DO publisher the orchestrator uses — publication_pending=1
on commit plus a best-effort post-commit notify (a dropped broadcast never fails
the discovery message; the sweep re-drives it). The rotation-drain contract is
unchanged: these pending rows drain exactly like the orchestrator's.

Follow-up to Sol review on #2115.

* fix(labeler): negate a deleted run's pending label before cancelling it

The delete path cancelled pending/running runs to terminal 'cancelled' before
issuing their assessment-pending negations. If issuance was paused (signing
rotation) the negation threw and the message retried, but on redelivery
listNonTerminalAssessmentsForUri excluded the already-cancelled runs — so there
was nothing left to negate, the message acked, and the active assessment-pending
label survived a deleted release forever. Each run's negation is now issued
before its terminal cancellation, so a paused/failed negation leaves the run
non-terminal and re-discoverable on redelivery; a run is retired only after its
negation commits, and the message cannot ack while any pending/running run is
un-negated. No active assessment-pending survives an acked delete.

Follow-up to Sol review on #2115.

* fix(labeler): guard the finalization CAS on the subject not being tombstoned

Finalization's isSubjectCurrent re-check is a separate read from its commit CAS, a
TOCTOU: a delete that tombstoned the subject in between still let the CAS commit
'running -> outcome' plus positive outcome/block labels, then the delete's own
cancel CAS conflicted and was swallowed — leaving live block labels on a deleted
release the evaluator would still honor. The finalization CAS now carries a
not-deleted predicate on the run's subject (same all-or-nothing batch idiom as
the F6 signing guard), so a tombstone landing before the batch no-ops the CAS
and every label gated on the outcome state; finalization retries and stales the
run out, never labelling a deleted subject.

Follow-up to Sol round-3 review on #2115.

* fix(labeler): retry (never dead-letter+ack) a delete-path mutation failure

The 'a negation throw prevents ack' invariant only held for recognized
issuance-unavailable errors; an unexpected signing/D1 failure during the delete's
tombstone/negate/cancel fell through to dead-letter+ack (the create-path policy),
leaving the run non-terminal with its assessment-pending label live on a deleted
subject forever. The delete handler now splits the absence-verification phase
(classified like create: transient retries, forged/permanent dead-letters) from
the mutation phase, which ALWAYS retries on failure so the label can never be
left live on an acked delete. A genuinely permanent fault exhausts to the DLQ via
max_retries — acceptable versus acking a stranded label.

Follow-up to Sol round-3 review on #2115.

* docs(labeler): correct the delete-mutation retry reference post-merge

After merging the integration branch (which added #2113's transient-DNS retry
backoff), the applyDiscoveryDelete docstring still said a failed negation
propagates to classifyDiscoveryError. Since the Blocker-2 fix, the delete
mutation phase has its own always-retry catch — update the comment to match.

No behavior change.

* fix(labeler): guard the initial pending-label issuance against a concurrent delete

The create path committed a recreated run as pending and THEN issued its positive
assessment-pending with no commit-time guard. A concurrent delete could, in the
gap, tombstone the subject, negate + cancel the (still-positive-less) run, and
ack; the create then committed its positive at a higher sequence, winning label
reduction — a live pending label on a tombstoned, cancelled subject. (Queue
invocations overlap; max_concurrency is unset.)

The initial issuance now goes through buildIssuanceStatements gated atomically on
BOTH the run still being pending AND the exact (uri,cid) subject still undeleted
(the same guard idiom as Blocker 1's finalization CAS). On a guard miss the label
no-ops: the issuance is obsolete, so the create neither publishes nor dispatches a
Workflow for a label that never committed; a non-persist not explained by the
guard (a signing flip) still retries. With this and Blocker 1, every automated-
positive issuance site in the discovery consumer is now delete-guarded (the
delete-path negation is negative and exempt).

Follow-up to Sol round-4 review on #2115.

* fix(labeler): guard the operator-rerun pending label against a concurrent delete

The operator rerun issued its positive assessment-pending with no commit-time
guard and while its run was still 'observed' (advance to 'pending' is deferred),
opening two integrity races with a concurrent discovery-delete (queue + HTTP
overlap; max_concurrency unset):
  (a) ordering: the rerun commits its run+pending after a delete tombstoned and
      snapshotted the non-terminal runs, so the delete never negates it and the
      positive survives on the tombstoned subject;
  (b) observed gap: the delete-path negation only fired for pending/running runs,
      so a delete that saw the rerun's 'observed' run cancelled it WITHOUT negating
      its already-live positive.

Fix (a): gate the rerun's assessment-pending issuance on requireSubjectNotDeleted
+ requireAssessmentState (threaded through prepareAutomatedLabelIssuance); on a
miss the label no-ops, assertIssuancePersisted aborts before the deferred tail, so
nothing is published, dispatched, or advanced. Fix (b): the delete-path negation
now fires for any non-terminal run that committed a positive pending (keyed on the
committed positive, not lifecycle state) — no dangling negations, no interleaving
where a cancelled/deleted run keeps a live positive. With Blocker 1 and the round-4
discovery guard, every positive assessment-pending issuance across the discovery
and console paths is now delete-safe.

Follow-up to adversary review on #2115.

* fix(labeler): structural delete-generation guard closing the create/verify/rerun-vs-delete race class

Adds a monotonic delete_generation to subjects (migration 0013). Every delete
increments it; create/verify/rerun capture it before reading state/verifying and
CAS-guard their subject-undelete, run creation, and label issuance on the
generation not having advanced. A decision made before a delete is rejected
obsolete; one that captured the post-delete generation still works (republish).

Closes three race seams: (1) createSubject un-delete is generation-gated so a
stale verify can't resurrect a deleted subject; (2) the delete cleanup now scans
pending-bearing runs INCLUDING terminal stale and negates any committed positive;
(3) the console rerun's run creation is generation-gated so no orphan observed run.

Implementation checkpoint; barrier tests for each seam + a republish test follow.

Refs Sol round-5 (systemic close).

* test(labeler): barrier tests for the delete-generation close (three seams + republish)

Seam 1: a stale verify (barrier during verify, full delete completes) cannot
resurrect the tombstoned subject — createSubject's generation-guarded undelete
no-ops, no run, no positive, no dispatch. Seam 2: the delete negates a terminal
stale run's stranded positive (widened scan). Seam 3: a rerun after a concurrent
tombstone leaves no orphan observed operator run. Plus a delete-then-republish
test proving a new revision after a delete assesses cleanly (the generation does
not over-block). Each verified failing pre-fix.

Refs Sol round-5 (systemic close).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cla: signed overlap review/needs-rereview Author pushed changes since the last review size/XL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant